SPB Git

Repository Public

page.tsx 38.0 KB 359 lines tsx

1import Link from "next/link";2import { notFound } from "next/navigation";3import { AutoRefresh } from "@/components/studio/AutoRefresh";4import { Meter, Notice, Panel, Status, ago, usd, when } from "@/components/studio/ui";5import { can, requireRole } from "@/lib/auth";6import { getPolicy, observedVideoRate } from "@/lib/budget";7import { CreativeBrief, critiqueScore, type Bible, type Critique, type Pitch, type TechnicalCheck } from "@/lib/contracts";8import { sql } from "@/lib/db";9import { env } from "@/lib/env";10import { storage } from "@/lib/storage";11import { NEXT, REQUIRES } from "@/pipeline/orchestrator";12import { STAGES, buildTimeline, currentScript, getShots, latestFilmVersion, shotDuration, type ProjectRow, type Stage } from "@/pipeline/stages";13import { estimateVideo } from "@/providers/pricing";14import { approveFilm, archiveProject, moveShot, publishFilm, regenerateShot, rejectFilm, runStage, selectPitch, setMode, updateShot } from "../../../actions";1516export const metadata = { title: "Project" };17type AssetRef = { id: string; sha256: string; ext: string };18const STAGE_LABEL: Record<Stage, [string, string]> = {19  concept: ["Concept", "Creative Director + critic: four scored pitches"], script: ["Script", "Screenwriter + Research Editor"], storyboard: ["Storyboard", "Bible, shots, image and motion prompts"],20  keyframes: ["Keyframes", "First frame of each shot"], voice: ["Voice", "Narration with word timings"], video: ["Video", "Image-to-video for each shot"],21  assemble: ["Assembly", "Cut, mix, captions, renditions"], qa: ["QA", "Technical checks + critic"],22};23const ORDER = ["draft", "pitched", "scripted", "storyboarded", "keyframes", "voiced", "filmed", "assembled", "qa"];24const rank = (status: string) => (["qa_passed", "qa_failed", "approved", "published"].includes(status) ? 8 : ORDER.indexOf(status));2526export default async function Project({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ ok?: string; err?: string; shot?: string }> }) {27  const { id } = await params;28  const sp = await searchParams;29  if (!/^[0-9a-f-]{36}$/.test(id)) notFound();30  const user = await requireRole("viewer");31  const editor = can(user, "editor"), owner = can(user, "owner");32  const [project] = await sql<(ProjectRow & { series_title: string | null })[]>`SELECT p.*, s.title_en AS series_title FROM projects p LEFT JOIN series s ON s.id = p.series_id WHERE p.id = ${id}`;33  if (!project) notFound();34  const brief = CreativeBrief.parse(project.brief);35  const [policy, candidates, sv, shots, fv, runs, jobs, costRow, film, approvals, storyboard] = await Promise.all([36    getPolicy(),37    sql<{ id: string; idx: number; pitch: Pitch; scores: Record<string, number | string | null>; total: number; selected: boolean }[]>`SELECT * FROM concept_candidates WHERE project_id = ${id} ORDER BY total DESC`,38    currentScript(id), getShots(id), latestFilmVersion(id),39    sql<{ id: string; stage: string; status: string; attempts: number; error: string | null; queued_at: Date; started_at: Date | null; finished_at: Date | null }[]>`SELECT * FROM stage_runs WHERE project_id = ${id} ORDER BY queued_at DESC LIMIT 12`,40    sql<{ id: string; shot_id: string | null; stage: string; kind: string; model: string; status: string; attempts: number; error: string | null; est_usd: number | null; cost_usd: number | null; cost_source: string | null; request_id: string | null; created_at: Date; finished_at: Date | null; asset_sha: string | null; asset_ext: string | null; settings: Record<string, unknown> }[]>`41      SELECT j.*, a.sha256 AS asset_sha, a.ext AS asset_ext FROM provider_jobs j LEFT JOIN assets a ON a.id = j.asset_id WHERE j.project_id = ${id} ORDER BY j.created_at DESC LIMIT 80`,42    sql<{ usd: number; failed: number }[]>`SELECT COALESCE(SUM(usd), 0)::float8 AS usd, COALESCE(SUM(usd) FILTER (WHERE failed), 0)::float8 AS failed FROM cost_entries WHERE project_id = ${id}`,43    sql<{ id: string; slug: string; status: string; published_at: Date | null; scheduled_for: Date | null; featured: boolean }[]>`SELECT id, slug, status, published_at, scheduled_for, featured FROM films WHERE project_id = ${id}`,44    sql<{ decision: string; note: string; at: Date; email: string | null }[]>`SELECT a.decision, a.note, a.at, u.email FROM approvals a LEFT JOIN users u ON u.id = a.user_id WHERE a.project_id = ${id} ORDER BY a.at DESC LIMIT 8`,45    sql<{ plan: { bible: Bible } }[]>`SELECT plan FROM storyboards WHERE project_id = ${id} ORDER BY version DESC LIMIT 1`,46  ]);47  const assetIds = [...shots.flatMap((s) => [s.image_asset_id, s.video_asset_id, s.audio_asset_id]), fv?.master_asset_id, fv?.poster_asset_id, fv?.manifest._assets.captions.en, fv?.manifest._assets.captions.fr].filter((x): x is string => !!x);48  const assets = assetIds.length ? await sql<AssetRef[]>`SELECT id, sha256, ext FROM assets WHERE id = ANY(${assetIds})` : [];49  const url = (assetId: string | null | undefined) => { const a = assets.find((x) => x.id === assetId); return a ? storage().getSignedUrl(a.sha256, a.ext, 6 * 3600) : null; };50  const signed = (sha: string | null, ext: string | null) => (sha && ext ? storage().getSignedUrl(sha, ext, 6 * 3600) : null);51  const [review] = fv ? await sql<{ technical: { checks: TechnicalCheck[]; reasons: string[] }; critique: Critique | null; score: number | null; passed: boolean; created_at: Date }[]>`SELECT * FROM quality_reviews WHERE film_version_id = ${fv.id} ORDER BY created_at DESC LIMIT 1` : [];52  const videoModel = project.video_model ?? env.videoModel;53  const rate = await observedVideoRate(videoModel, env.videoResolution);54  const pendingVideo = shots.filter((s) => !s.video_asset_id);55  const videoEst = pendingVideo.reduce((n, s) => n + (estimateVideo(videoModel, env.videoResolution, Math.ceil(shotDuration(s.narration_duration)), rate).usd ?? 0), 0);56  const videoBasis = estimateVideo(videoModel, env.videoResolution, 6, rate).basis;57  const tl = shots.length ? buildTimeline(shots) : null;58  const sel = shots.find((s) => s.id === sp.shot) ?? null;59  const versions = sel ? await sql<{ kind: string; version: number; prompt: string; author: string; created_at: Date }[]>`SELECT kind, version, prompt, author, created_at FROM prompt_versions WHERE shot_id = ${sel.id} ORDER BY kind, version DESC` : [];60  const busy = !!project.running_stage;61  const r = rank(project.status);62  const next = NEXT[project.status];63  const P = `/studio/projects/${id}`;64  const hidden = <input type="hidden" name="project" value={id} />;6566  return (67    <>68      <AutoRefresh seconds={4} active={busy} />69      <div className="mb-5">70        <Link href="/studio/projects" className="label hover:text-[var(--color-text)]">← Projects</Link>71        <div className="mt-3 flex flex-wrap items-start justify-between gap-4">72          <div className="min-w-0">73            <h1 className="display text-4xl md:text-5xl">{project.title}</h1>74            <p className="mt-2 flex flex-wrap items-center gap-2 text-sm text-[var(--color-faint)]">75              {busy ? <Status value="running" label={`running: ${project.running_stage}`} /> : <Status value={project.status} />}76              <span>{project.series_title ?? "Standalone"} · {project.language} · <span className="mono">{videoModel}</span> · {project.origin} · updated {ago(project.updated_at)}</span>77            </p>78          </div>79          <div className="w-full max-w-xs"><Meter label="Film budget" spent={costRow[0].usd} cap={Number(policy.per_film_usd)} />{costRow[0].failed > 0 && <p className="mt-1 text-xs text-[var(--color-bad)]">{usd(costRow[0].failed)} spent on failed generations</p>}</div>80        </div>81      </div>82      <Notice ok={sp.ok} err={sp.err} />83      {project.blocked_reason && <Notice err={`Blocked by the budget policy: ${project.blocked_reason}. Raise the ceiling in Settings or release the kill switch, then run the stage again.`} />}84      {project.error && !busy && <Notice err={`Last stage failed: ${project.error}`} />}8586      {/* Stage rail */}87      <Panel title="Pipeline" action={editor ? (88        <form action={setMode} className="flex items-center gap-2 text-xs">{hidden}<label htmlFor="mode" className="text-[var(--color-faint)]">Autonomy</label>89          <select id="mode" name="mode" defaultValue={project.mode} className="field !min-h-8 !w-auto !py-1 text-xs"><option value="manual">Manual</option><option value="assisted">Assisted</option><option value="autonomous">Autonomous with budget</option></select>90          <button className="btn btn-ghost btn-sm">Set</button></form>) : undefined}>91        <ol className="grid gap-2 sm:grid-cols-4 lg:grid-cols-8">92          {STAGES.map((stage, i) => {93            const done = r > i, current = next === stage, running = project.running_stage === stage;94            const allowed = REQUIRES[stage].includes(project.status);95            const last = runs.find((x) => x.stage === stage);96            return (97              <li key={stage} className={`rounded-xl border p-3 ${running ? "border-[var(--color-lamp)]" : current ? "border-[var(--color-line)] bg-[var(--color-raised)]" : "border-[var(--color-line-soft)]"}`}>98                <p className="flex items-center justify-between text-[0.68rem] uppercase tracking-[0.14em] text-[var(--color-faint)]"><span>{String(i + 1).padStart(2, "0")}</span><span className={running ? "text-[var(--color-accent)]" : done ? "text-[var(--color-ok)]" : last?.status === "failed" ? "text-[var(--color-bad)]" : last?.status === "blocked" ? "text-[var(--color-warn)]" : ""}>{running ? "running" : done ? "done" : last?.status === "failed" || last?.status === "blocked" ? last.status : current ? "next" : ""}</span></p>99                <p className="mt-1 font-medium">{STAGE_LABEL[stage][0]}</p>100                <p className="mt-0.5 min-h-8 text-[0.7rem] leading-snug text-[var(--color-faint)]">{STAGE_LABEL[stage][1]}</p>101                {stage === "video" && pendingVideo.length > 0 && shots.length > 0 && <p className="mt-1 text-[0.7rem] text-[var(--color-accent)]">≈ {usd(videoEst)} for {pendingVideo.length} shot{pendingVideo.length > 1 ? "s" : ""} ({videoBasis})</p>}102                {editor && allowed && !busy && (103                  <form action={runStage} className="mt-2 flex gap-1">{hidden}<input type="hidden" name="stage" value={stage} />104                    <button className={`btn btn-sm flex-1 ${current ? "btn-primary" : "btn-ghost"}`} disabled={policy.kill_switch}>{done ? "Re-run" : "Run"}</button>105                    {done && ["concept", "script", "storyboard", "qa"].includes(stage) && <button name="fresh" value="1" className="btn btn-ghost btn-sm" title="Ask for a new answer instead of reusing the stored one" disabled={policy.kill_switch}>New</button>}106                  </form>107                )}108              </li>109            );110          })}111        </ol>112        <p className="mt-3 text-xs text-[var(--color-faint)]">Re-running a stage is safe: finished provider calls are reused, so nothing is paid twice. “New” asks the model for a different answer. {project.mode === "assisted" && "Assisted mode pauses before Video."}</p>113      </Panel>114115      {/* Brief + pitches */}116      <div className="mt-5 grid gap-5 lg:grid-cols-[1fr_2fr]">117        <Panel title="Creative brief">118          <dl className="space-y-3 text-sm">119            {([["Theme", brief.theme], ["Objective", brief.objective], ["Audience", brief.audience], ["Mood", brief.mood], ["Genre", brief.genre], ["Constraints", brief.constraints], ["Shots", String(brief.shots)]] as const).filter(([, v]) => v).map(([k, v]) => <div key={k}><dt className="label">{k}</dt><dd className="mt-0.5 text-[var(--color-muted)]">{v}</dd></div>)}120          </dl>121          {editor && project.status !== "published" && !busy && <form action={archiveProject} className="mt-5">{hidden}<button className="text-xs text-[var(--color-faint)] underline">Archive this project</button></form>}122        </Panel>123        <Panel title={`Pitches (${candidates.length})`}>124          {candidates.length ? (125            <ul className="grid gap-3 md:grid-cols-2">126              {candidates.map((c) => (127                <li key={c.id} className={`rounded-xl border p-4 ${c.selected ? "border-[var(--color-lamp)] bg-[var(--color-raised)]" : "border-[var(--color-line-soft)]"}`}>128                  <div className="flex items-start justify-between gap-3"><h3 className="display text-2xl">{c.pitch.title}</h3><span className="display text-3xl tabular-nums text-[var(--color-accent)]">{Number(c.total).toFixed(1)}</span></div>129                  <p className="mt-2 text-sm text-[var(--color-muted)]">{c.pitch.logline}</p>130                  <p className="mt-2 text-sm"><span className="label">Thesis </span>{c.pitch.thesis}</p>131                  <details className="mt-2 text-xs text-[var(--color-muted)]"><summary className="cursor-pointer text-[var(--color-faint)]">Arc, world and risks</summary>132                    <p className="mt-2"><b>Hook.</b> {c.pitch.hook}</p><p className="mt-1"><b>Arc.</b> {c.pitch.setup} → {c.pitch.tension} → {c.pitch.payoff}</p><p className="mt-1"><b>World.</b> {c.pitch.visualWorld}</p><p className="mt-1"><b>Artifact risks.</b> {c.pitch.artifactRisks}</p>133                  </details>134                  <p className="mt-3 text-[0.7rem] tabular-nums text-[var(--color-faint)]">originality {c.scores.originality} · feasibility {c.scores.feasibility} · visual {c.scores.visualPotential} · clarity {c.scores.narrativeClarity} · artifact risk {c.scores.artifactRisk}</p>135                  {c.scores.note && <p className="mt-1 text-xs italic text-[var(--color-faint)]">{String(c.scores.note)}</p>}136                  {c.scores.duplicateOf && <p className="mt-1 text-xs text-[var(--color-warn)]">Too close to “{String(c.scores.duplicateOf)}” (similarity {String(c.scores.similarity)}): −3 points.</p>}137                  <div className="mt-3">{c.selected ? <Status value="approved" label="selected" /> : editor && !busy && r <= 3 && <form action={selectPitch}>{hidden}<input type="hidden" name="candidate" value={c.id} /><button className="btn btn-ghost btn-sm">Select this pitch</button></form>}</div>138                </li>139              ))}140            </ul>141          ) : <p className="text-sm text-[var(--color-faint)]">{busy ? "The Creative Director is writing pitches…" : "No pitch yet. Run the Concept stage."}</p>}142        </Panel>143      </div>144145      {/* Script + research */}146      {sv && (147        <div className="mt-5 grid gap-5 lg:grid-cols-[2fr_1fr]">148          <Panel title={`Script — version ${sv.version} (${sv.author})`}>149            <p className="display text-3xl">{sv.script.titleEn} <span className="text-[var(--color-faint)]">/ {sv.script.titleFr}</span></p>150            <p className="mt-2 text-sm text-[var(--color-muted)]">{sv.script.synopsisEn}</p>151            <p className="mt-1 text-sm text-[var(--color-faint)]" lang="fr">{sv.script.synopsisFr}</p>152            <p className="mt-3 text-sm"><span className="label">Takeaway </span>{sv.script.takeawayEn}</p>153            <p className="mt-1 text-xs text-[var(--color-faint)]">Narrator: preset voice “{sv.script.voiceId}” — {sv.script.voiceDirection}</p>154            <ol className="mt-4 space-y-2">155              {sv.script.beats.map((b, i) => <li key={i} className="grid grid-cols-[2rem_1fr] gap-2 text-sm"><span className="tabular-nums text-[var(--color-faint)]">{i + 1}</span><span><span className="mr-2 text-[0.65rem] uppercase tracking-[0.14em] text-[var(--color-accent)]">{b.purpose} · {b.lang}</span>{b.narration}<br /><span className="text-xs text-[var(--color-faint)]">{b.narrationAlt}</span></span></li>)}156            </ol>157            <p className="mt-3 text-xs text-[var(--color-faint)]">Edit individual lines in the shot inspector below (each edit is a new version).</p>158          </Panel>159          <Panel title="Research Editor">160            {!sv.research ? <p className="text-sm text-[var(--color-muted)]">Fiction: the script makes no factual claim, so nothing needs checking.</p> : sv.research.claims?.length ? (161              <ul className="space-y-3 text-sm">162                {sv.research.claims.map((c, i) => (163                  <li key={i}><Status value={c.verdict === "supported" ? "done" : c.verdict === "inaccurate" ? "failed" : "blocked"} label={c.verdict} /><p className="mt-1">{c.claim}</p>{c.correction && <p className="mt-1 text-xs text-[var(--color-warn)]">Correction: {c.correction}</p>}164                    <ul className="mt-1 space-y-0.5 text-xs">{c.sources.map((s) => <li key={s.url}><a href={s.url} target="_blank" rel="noopener noreferrer" className="break-all text-[var(--color-muted)] underline">{s.title || s.url}</a></li>)}</ul></li>165                ))}166              </ul>167            ) : <p className="text-sm text-[var(--color-muted)]">{sv.research.note ?? "No factual claim declared."}</p>}168            {sv.research?.checkedAt && <p className="mt-3 text-xs text-[var(--color-faint)]">Checked {when(sv.research.checkedAt)} with web search. {sv.research.unresolved ? `${sv.research.unresolved} claim(s) unresolved: QA will hold the film.` : "All claims supported."}</p>}169          </Panel>170        </div>171      )}172173      {/* Storyboard */}174      {shots.length > 0 && (175        <div className="mt-5">176          <Panel title={`Storyboard — ${shots.length} shots`}>177            {storyboard[0] && (178              <details className="mb-4 text-sm text-[var(--color-muted)]"><summary className="cursor-pointer text-[var(--color-faint)]">Style and character bible</summary>179                <dl className="mt-3 grid gap-3 sm:grid-cols-2">{(["palette", "lighting", "lens", "world", "avoid"] as const).map((k) => <div key={k}><dt className="label">{k}</dt><dd className="mt-0.5">{storyboard[0].plan.bible[k]}</dd></div>)}180                  <div><dt className="label">Continuity anchors</dt><dd className="mt-0.5">{storyboard[0].plan.bible.anchors.join(" · ")}</dd></div>181                  {storyboard[0].plan.bible.characters.length > 0 && <div className="sm:col-span-2"><dt className="label">Characters</dt><dd className="mt-0.5">{storyboard[0].plan.bible.characters.map((c) => `${c.name}: ${c.description}`).join(" — ")}</dd></div>}</dl>182              </details>183            )}184            <ul className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">185              {shots.map((s) => {186                const img = url(s.image_asset_id), vid = url(s.video_asset_id);187                return (188                  <li key={s.id} id={`shot-${s.id}`} className={`rounded-xl border p-2 ${sel?.id === s.id ? "border-[var(--color-lamp)]" : "border-[var(--color-line-soft)]"}`}>189                    <div className="relative aspect-[9/16] overflow-hidden rounded-lg bg-black">190                      {vid ? <video src={vid} poster={img ?? undefined} controls preload="none" playsInline className="h-full w-full object-cover" aria-label={`Shot ${s.idx + 1} clip`} /> : img ? <img src={img} alt={`Keyframe of shot ${s.idx + 1}`} className="h-full w-full object-cover" loading="lazy" /> : <div className="grid h-full place-items-center text-xs text-[var(--color-faint)]">no keyframe</div>}191                      <span className="absolute left-1.5 top-1.5 rounded bg-black/70 px-1.5 text-xs tabular-nums">{s.idx + 1}</span>192                    </div>193                    <p className="mt-2 truncate text-sm font-medium">{s.title || `Shot ${s.idx + 1}`}</p>194                    <p className="mt-0.5 line-clamp-2 min-h-8 text-xs text-[var(--color-muted)]">{s.narration}</p>195                    <p className="mt-1 flex flex-wrap items-center gap-1 text-[0.68rem] text-[var(--color-faint)]">196                      <Status value={s.error ? "failed" : s.video_asset_id ? "filmed" : s.status} label={s.error ? "error" : s.video_asset_id ? "clip" : s.audio_asset_id && s.image_asset_id ? "ready" : s.image_asset_id ? "keyframe" : "planned"} />197                      {s.narration_duration !== null && <span className="tabular-nums">{shotDuration(s.narration_duration).toFixed(1)} s</span>}{s.regen_count > 0 && <span>· {s.regen_count} regen</span>}198                    </p>199                    {s.error && <p className="mt-1 break-words text-[0.68rem] text-[var(--color-bad)]">{s.error}</p>}200                    <Link href={`${P}?shot=${s.id}#inspector`} scroll={false} className="btn btn-ghost btn-sm mt-2 w-full">Inspect</Link>201                  </li>202                );203              })}204            </ul>205206            {tl && shots.some((s) => s.narration_duration !== null) && (207              <div className="mt-5">208                <p className="label mb-2">Timeline — {tl.total.toFixed(1)} s</p>209                <div className="flex h-12 gap-0.5 overflow-hidden rounded-lg" role="img" aria-label={`Timeline of ${shots.length} shots, ${tl.total.toFixed(1)} seconds`}>210                  {tl.shots.map((t, i) => (211                    <Link key={i} href={`${P}?shot=${shots[i].id}#inspector`} scroll={false} style={{ width: `${(t.duration / tl.total) * 100}%` }} className="relative bg-[var(--color-raised)] hover:bg-[var(--color-line)]" title={`Shot ${i + 1}: ${t.start.toFixed(1)}–${(t.start + t.duration).toFixed(1)} s${t.transition === "fade" ? " (fade in)" : ""}`}>212                      <span className="absolute inset-y-2 rounded-sm bg-[color-mix(in_srgb,var(--color-lamp)_55%,transparent)]" style={{ left: `${((t.narrationStart - t.start) / t.duration) * 100}%`, width: `${((t.narrationEnd - t.narrationStart) / t.duration) * 100}%` }} />213                      <span className="absolute left-1 top-0.5 text-[0.62rem] tabular-nums text-[var(--color-text)]">{i + 1}{t.transition === "fade" ? " ◐" : ""}</span>214                    </Link>215                  ))}216                </div>217                <p className="mt-1 text-xs text-[var(--color-faint)]">Amber = narration. Each shot lasts as long as its line needs, plus a short lead and tail. ◐ = fade.</p>218              </div>219            )}220          </Panel>221        </div>222      )}223224      {/* Shot inspector / Prompt Workbench */}225      {sel && (226        <div className="mt-5" id="inspector">227          <Panel title={`Shot ${sel.idx + 1} — inspector and prompt workbench`} action={<Link href={P} scroll={false} className="text-xs text-[var(--color-faint)] underline">Close</Link>}>228            <div className="grid gap-5 lg:grid-cols-[220px_1fr_1fr]">229              <div>230                <div className="aspect-[9/16] overflow-hidden rounded-lg bg-black">{url(sel.video_asset_id) ? <video src={url(sel.video_asset_id)!} poster={url(sel.image_asset_id) ?? undefined} controls playsInline className="h-full w-full object-cover" /> : url(sel.image_asset_id) ? <img src={url(sel.image_asset_id)!} alt="Keyframe" className="h-full w-full object-cover" /> : null}</div>231                {url(sel.audio_asset_id) && <audio src={url(sel.audio_asset_id)!} controls preload="none" className="mt-2 w-full" aria-label="Narration" />}232                <dl className="mt-3 space-y-1 text-xs text-[var(--color-muted)]"><div><dt className="inline text-[var(--color-faint)]">Camera: </dt><dd className="inline">{sel.camera}</dd></div><div><dt className="inline text-[var(--color-faint)]">Action: </dt><dd className="inline">{sel.action}</dd></div><div><dt className="inline text-[var(--color-faint)]">Continuity: </dt><dd className="inline">{sel.continuity}</dd></div><div><dt className="inline text-[var(--color-faint)]">Transition in: </dt><dd className="inline">{sel.transition}</dd></div></dl>233                {editor && !busy && project.status !== "published" && (234                  <div className="mt-3 space-y-2">235                    <div className="flex gap-2">{([-1, 1] as const).map((d) => <form key={d} action={moveShot} className="flex-1">{hidden}<input type="hidden" name="shot" value={sel.id} /><input type="hidden" name="dir" value={d} /><button className="btn btn-ghost btn-sm w-full" disabled={(d === -1 && sel.idx === 0) || (d === 1 && sel.idx === shots.length - 1)}>{d === -1 ? "← Earlier" : "Later →"}</button></form>)}</div>236                    {(["image", "voice", "video"] as const).map((what) => {237                      const needs = what !== "voice" && sel.regen_count >= policy.max_regenerations && !sel.regen_approved;238                      return (239                        <form key={what} action={regenerateShot}>{hidden}<input type="hidden" name="shot" value={sel.id} /><input type="hidden" name="what" value={what} />240                          {needs && owner && <input type="hidden" name="approve" value="1" />}241                          <button className={`btn btn-sm w-full ${needs ? "btn-danger" : "btn-ghost"}`} disabled={policy.kill_switch || (needs && !owner)}>{needs ? (owner ? `Approve + regenerate ${what}` : `Regenerate ${what} (owner approval needed)`) : what === "image" ? "Regenerate keyframe" : what === "voice" ? "Replace voice take" : "Regenerate clip"}</button>242                        </form>243                      );244                    })}245                    <p className="text-[0.68rem] text-[var(--color-faint)]">{sel.regen_count} of {policy.max_regenerations} regenerations used. Beyond the limit an owner must approve.</p>246                  </div>247                )}248              </div>249              <form action={updateShot} className="space-y-3">{hidden}<input type="hidden" name="shot" value={sel.id} />250                <div><label htmlFor="narration" className="label">Narration (spoken, {sel.timestamps?.lang ?? "en"})</label><textarea id="narration" name="narration" rows={3} defaultValue={sel.narration} maxLength={400} className="field mt-1" readOnly={!editor} /></div>251                <div><label htmlFor="narrationAlt" className="label">Caption adaptation (other language)</label><textarea id="narrationAlt" name="narrationAlt" rows={2} defaultValue={sel.narration_alt} maxLength={400} className="field mt-1" readOnly={!editor} /></div>252                <div><label htmlFor="imagePrompt" className="label">Image prompt (first frame)</label><textarea id="imagePrompt" name="imagePrompt" rows={7} defaultValue={sel.image_prompt} maxLength={4000} className="field mt-1 text-xs" readOnly={!editor} /></div>253                <div><label htmlFor="videoPrompt" className="label">Motion prompt (image-to-video)</label><textarea id="videoPrompt" name="videoPrompt" rows={5} defaultValue={sel.video_prompt} maxLength={4000} className="field mt-1 text-xs" readOnly={!editor} /></div>254                {editor && <button className="btn btn-primary" disabled={busy || project.status === "published"}>Save as new version</button>}255                <p className="text-xs text-[var(--color-faint)]">Saving keeps every previous version. A changed prompt invalidates the asset that depended on it; a changed line also invalidates the clip, whose length follows the narration.</p>256              </form>257              <div>258                <p className="label">Variants generated for this shot</p>259                <ul className="mt-2 grid grid-cols-3 gap-2">260                  {jobs.filter((x) => x.shot_id === sel.id && x.asset_sha && (x.kind === "image" || x.kind === "video")).map((x) => (261                    <li key={x.id} className="text-[0.65rem] text-[var(--color-faint)]">262                      <div className="aspect-[9/16] overflow-hidden rounded bg-black">{x.kind === "video" ? <video src={signed(x.asset_sha, x.asset_ext)!} controls preload="none" playsInline className="h-full w-full object-cover" /> : <img src={signed(x.asset_sha, x.asset_ext)!} alt="" className="h-full w-full object-cover" loading="lazy" />}</div>263                      <p className="mt-1">{x.kind} · {usd(x.cost_usd, 3)}<br />{ago(x.created_at)}</p>264                    </li>265                  ))}266                </ul>267                <p className="label mt-4">Prompt history</p>268                <ul className="mt-2 max-h-80 space-y-2 overflow-y-auto pr-1 text-xs">269                  {versions.map((v) => <li key={`${v.kind}${v.version}`} className="rounded-lg border border-[var(--color-line-soft)] p-2"><p className="text-[var(--color-faint)]">{v.kind} v{v.version} · {v.author} · {ago(v.created_at)}</p><p className="mt-1 whitespace-pre-wrap break-words text-[var(--color-muted)]">{v.prompt}</p></li>)}270                </ul>271              </div>272            </div>273          </Panel>274        </div>275      )}276277      {/* Preview, QA and release */}278      {fv && (279        <div className="mt-5 grid gap-5 lg:grid-cols-[300px_1fr]">280          <Panel title={`Cut v${fv.version} — ${Number(fv.duration).toFixed(1)} s`}>281            <div className="aspect-[9/16] overflow-hidden rounded-lg bg-black">282              <video src={url(fv.master_asset_id) ?? undefined} poster={url(fv.poster_asset_id) ?? undefined} controls playsInline crossOrigin="anonymous" className="h-full w-full object-contain" aria-label="Film preview">283                {url(fv.manifest._assets.captions.en) && <track kind="captions" srcLang="en" label="English" src={url(fv.manifest._assets.captions.en)!} default />}284                {url(fv.manifest._assets.captions.fr) && <track kind="captions" srcLang="fr" label="Français" src={url(fv.manifest._assets.captions.fr)!} />}285              </video>286            </div>287            <p className="mt-2 text-xs text-[var(--color-faint)]">Assembled {when(fv.created_at)}. {r < 7 && "The shots changed since this cut: run Assembly again."}</p>288          </Panel>289          <div className="space-y-5">290            <Panel title="Automated QA">291              {review ? (292                <>293                  <div className="flex flex-wrap items-center gap-3"><Status value={review.passed ? "qa_passed" : "qa_failed"} label={review.passed ? "passed" : "failed"} />{review.score !== null && <span className="display text-3xl tabular-nums">{Number(review.score).toFixed(1)}<span className="text-base text-[var(--color-faint)]"> / 10 · threshold {Number(policy.qa_threshold).toFixed(1)}</span></span>}<span className="text-xs text-[var(--color-faint)]">{when(review.created_at)}</span></div>294                  {review.technical.reasons?.length > 0 && <ul className="mt-3 list-disc space-y-1 pl-5 text-sm text-[var(--color-bad)]">{review.technical.reasons.map((x, i) => <li key={i}>{x}</li>)}</ul>}295                  <div className="mt-4 grid gap-5 md:grid-cols-2">296                    <div><p className="label mb-2">Technical checks</p><ul className="space-y-1.5 text-xs">{review.technical.checks.map((c) => <li key={c.id} className="flex gap-2"><span aria-hidden="true" className={c.ok ? "text-[var(--color-ok)]" : c.severity === "fail" ? "text-[var(--color-bad)]" : "text-[var(--color-warn)]"}>{c.ok ? "✓" : c.severity === "fail" ? "✕" : "!"}</span><span><span className="sr-only">{c.ok ? "Passed: " : "Failed: "}</span>{c.label} <span className="text-[var(--color-faint)]">— {c.detail}</span></span></li>)}</ul></div>297                    <div><p className="label mb-2">Critic rubric</p>{review.critique ? (298                      <>299                        <ul className="space-y-1.5 text-xs">{([["Narrative coherence", review.critique.narrativeCoherence], ["Visual fidelity", review.critique.visualFidelity], ["Continuity", review.critique.continuity], ["Audio intelligibility", review.critique.audioIntelligibility], ["Factual integrity", review.critique.factualIntegrity], ["Accessibility", review.critique.accessibility], ["Safety", review.critique.safety]] as const).map(([k, v]) => (300                          <li key={k} className="grid grid-cols-[9.5rem_1fr_1.6rem] items-center gap-2"><span>{k}</span><span className="h-1.5 overflow-hidden rounded-full bg-[var(--color-line)]"><span className="block h-full rounded-full" style={{ width: `${v * 10}%`, background: v >= 7 ? "var(--color-ok)" : v >= 5 ? "var(--color-warn)" : "var(--color-bad)" }} /></span><span className="text-right tabular-nums">{v}</span></li>))}</ul>301                        <p className="mt-3 text-xs text-[var(--color-muted)]">{review.critique.summary} <span className="text-[var(--color-faint)]">(mean {critiqueScore(review.critique)})</span></p>302                      </>) : <p className="text-xs text-[var(--color-warn)]">The critic could not run.</p>}</div>303                  </div>304                  {review.critique && review.critique.issues.length > 0 && (305                    <div className="mt-4"><p className="label mb-2">Issues and suggested fixes</p><ul className="space-y-2 text-xs">{review.critique.issues.map((x, i) => (306                      <li key={i} className="rounded-lg border border-[var(--color-line-soft)] p-2"><Status value={x.severity === "blocking" ? "failed" : x.severity === "major" ? "blocked" : "queued"} label={`${x.severity}${x.shot ? ` · shot ${x.shot}` : ""}`} /><p className="mt-1">{x.note}</p><p className="mt-1 text-[var(--color-faint)]">Fix: {x.fix}</p>307                        {x.shot > 0 && shots[x.shot - 1] && <Link href={`${P}?shot=${shots[x.shot - 1].id}#inspector`} scroll={false} className="mt-1 inline-block underline">Open shot {x.shot}</Link>}</li>))}</ul></div>308                  )}309                </>310              ) : <p className="text-sm text-[var(--color-faint)]">QA has not run on this cut.</p>}311            </Panel>312313            <Panel title="Approval and release">314              {film[0] && <p className="mb-3 text-sm"><Status value={film[0].status} /> {film[0].status === "published" && <a href={`/film/${film[0].slug}`} className="ml-2 underline">/film/{film[0].slug}</a>}{film[0].status === "scheduled" && <span className="ml-2 text-[var(--color-muted)]">release {when(film[0].scheduled_for)}</span>}</p>}315              {!editor ? <p className="text-sm text-[var(--color-faint)]">Your role cannot approve or publish.</p> : busy ? <p className="text-sm text-[var(--color-faint)]">Wait for the running stage.</p> : (316                <div className="space-y-4">317                  {project.status === "qa_passed" && <form action={approveFilm} className="flex flex-wrap items-end gap-3">{hidden}<div className="min-w-[16rem] flex-1"><label htmlFor="anote" className="label">Note (optional)</label><input id="anote" name="note" maxLength={300} className="field mt-1" /></div><button className="btn btn-primary">Approve for release</button></form>}318                  {project.status === "qa_failed" && (319                    <form action={approveFilm} className="space-y-2 rounded-xl border border-[color-mix(in_srgb,var(--color-bad)_45%,transparent)] p-3">{hidden}<input type="hidden" name="override" value="1" />320                      <p className="text-sm text-[var(--color-bad)]">QA failed. This film cannot be published unless an owner overrides the result with a written reason, which is recorded.</p>321                      <label htmlFor="onote" className="label">Reason for the override (required)</label><textarea id="onote" name="note" rows={2} minLength={10} required className="field" disabled={!owner} />322                      <button className="btn btn-danger" disabled={!owner}>{owner ? "Override QA and approve" : "Owner only"}</button>323                    </form>324                  )}325                  {["qa_passed", "qa_failed", "approved"].includes(project.status) && <form action={rejectFilm} className="flex flex-wrap items-end gap-3">{hidden}<div className="min-w-[16rem] flex-1"><label htmlFor="rnote" className="label">Reject with a note</label><input id="rnote" name="note" maxLength={300} className="field mt-1" /></div><button className="btn btn-ghost">Reject</button></form>}326                  {["approved", "published"].includes(project.status) && (327                    <form action={publishFilm} className="flex flex-wrap items-end gap-3">{hidden}328                      <div><label htmlFor="sched" className="label">Schedule (optional, local time)</label><input id="sched" name="scheduleFor" type="datetime-local" className="field mt-1" /></div>329                      <label className="flex min-h-11 items-center gap-2 text-sm"><input type="checkbox" name="featured" value="1" /> Make it the homepage hero</label>330                      <button className="btn btn-primary">{project.status === "published" ? "Republish this cut" : "Publish"}</button>331                    </form>332                  )}333                  {!["qa_passed", "qa_failed", "approved", "published"].includes(project.status) && <p className="text-sm text-[var(--color-faint)]">Run QA on the current cut before approval.</p>}334                </div>335              )}336              {approvals.length > 0 && <ul className="mt-4 space-y-1 border-t border-[var(--color-line-soft)] pt-3 text-xs text-[var(--color-muted)]">{approvals.map((a, i) => <li key={i}>{when(a.at)} — <b>{a.decision.replace(/_/g, " ")}</b> by {a.email ?? "policy"}{a.note ? `: ${a.note}` : ""}</li>)}</ul>}337            </Panel>338          </div>339        </div>340      )}341342      {/* Jobs */}343      <div className="mt-5 grid gap-5 lg:grid-cols-[1fr_2fr]">344        <Panel title="Stage runs">345          {runs.length ? <ul className="space-y-2 text-xs">{runs.map((x) => <li key={x.id}><div className="flex items-center justify-between gap-2"><span>{x.stage} <span className="text-[var(--color-faint)]">· attempt {x.attempts || 1} · {ago(x.queued_at)}</span></span><Status value={x.status} /></div>{x.error && <p className="mt-0.5 break-words text-[var(--color-bad)]">{x.error}</p>}</li>)}</ul> : <p className="text-sm text-[var(--color-faint)]">No run yet.</p>}346        </Panel>347        <Panel title="Provider calls (provenance and cost)">348          {jobs.length ? (349            <div className="max-h-96 overflow-auto"><table className="studio-table"><thead><tr><th>When</th><th>Stage</th><th>Kind · model</th><th>State</th><th>Estimate</th><th>Actual</th></tr></thead><tbody>350              {jobs.map((x) => <tr key={x.id}><td className="whitespace-nowrap text-[var(--color-muted)]">{ago(x.created_at)}</td><td>{x.stage}{x.shot_id ? ` · shot ${(shots.find((s) => s.id === x.shot_id)?.idx ?? -1) + 1 || "?"}` : ""}</td><td><span className="mono">{x.kind} · {x.model}</span>{x.request_id && <span className="block text-[0.65rem] text-[var(--color-faint)]">request {x.request_id.slice(0, 13)}…</span>}</td>351                <td><Status value={x.status} label={`${x.status}${x.attempts > 1 ? ` ×${x.attempts}` : ""}`} />{x.error && <p className="mt-1 max-w-xs break-words text-[0.68rem] text-[var(--color-bad)]">{x.error}</p>}</td><td className="tabular-nums">{usd(x.est_usd, 3)}</td><td className="tabular-nums">{x.status === "done" || x.cost_usd !== null ? usd(x.cost_usd, 4) : "—"}{x.cost_source && <span className="block text-[0.65rem] text-[var(--color-faint)]">{x.cost_source === "ticks" ? "billed" : x.cost_source}</span>}</td></tr>)}352            </tbody></table></div>353          ) : <p className="text-sm text-[var(--color-faint)]">No provider call yet.</p>}354        </Panel>355      </div>356    </>357  );358}359
Files main
Menu

Theme